<?php
/**
 * Stash
 *
 * Copyright (c) 2009-2011, Robert Hafner <tedivm@tedivm.com>.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *   * Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 *
 *   * Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in
 *     the documentation and/or other materials provided with the
 *     distribution.
 *
 *   * Neither the name of Robert Hafner nor the names of his
 *     contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
 * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
 * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
 * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
 * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 *
 * @package    Stash
 * @subpackage Handlers
 * @author     Robert Hafner <tedivm@tedivm.com>
 * @copyright  2009-2011 Robert Hafner <tedivm@tedivm.com>
 * @license    http://www.opensource.org/licenses/bsd-license.php  BSD License
 * @link       http://code.google.com/p/stash/
 * @since      File available since Release 0.9.1
 * @version    Release: 0.9.1
 */



/**
 * This class is used by the Cache class for persistent storage of cached objects using multiple sqlite files.
 *
 * @package System
 * @subpackage Caching
 */
class StashSqlite implements StashHandler
{
	/**
	 * This is a string that represents the item being managed.
	 *
	 * @var string
	 */
	protected $key;

	/**
	 * This is the data being stored
	 *
	 * @var array
	 */
	protected $data;

	/**
	 * This is the name of the current section, identified by being the first string in the key.
	 *
	 * @var string
	 */
	protected $section;

	/**
	 * Defines how the PDO or Sqlite libraries return data. This is defined based off of which back end ultimately
	 * gets used, but in both cases it should be the constant representing associative arrays.
	 *
	 */
	protected $responseCode;

	/**
	 * This is a stored sqlObject using the cache database. This way each cache call does not need to open the handler
	 * again, saving a bit of overhead.
	 *
	 * @var SQLiteDatabase
	 */
	static protected $sqlObject = false;

	/**
	 * The time, in miliseconds, that the cacheHandler should wait for results. If this number is too high it makes it
	 * possible, in some situations, for the cacheHandler to actually slow down, instead of speed up, a request.
	 *
	 * @var int
	 */
	protected $busyTimeout;

	protected $creationSql = 'CREATE TABLE cacheStore (
							key TEXT UNIQUE ON CONFLICT REPLACE,
							expires INTEGER,
							encoding TEXT,
							data BLOB
						);
						CREATE INDEX keyIndex ON cacheStore (key);';


	protected $filePermissions;
	protected $dirPermissions;

	protected $defaultOptions = array(
										'filePermissions'	=> 0660,
										'dirPermissions'	=> 0770,
										'busyTimeout'		=> 500
									  );

	/**
	 * This is the base path for the cache items to be saved in. This defaults to a directory in the tmp directory (as
	 * defined by the configuration) called 'stash_', which it will create if needed.
	 *
	 * @var string
	 */
	protected $cachePath;

	public function __construct($options = array())
	{
		$options = array_merge($this->defaultOptions, $options);

		$this->cachePath = isset($options['path']) ? $options['path'] : StashUtilities::getBaseDirectory($this);
		$lastChar = substr($this->cachePath, -1);
		if($lastChar != '/' && $lastChar != '\'')
			$this->cachePath .= '/';

		$this->filePermissions = $options['filePermissions'];
		$this->dirPermissions = $options['dirPermissions'];
	}

	/**
	 * This returns the data from the SQLite database
	 *
	 * @return array
	 */
	public function getData($key)
	{
		$sqlKey = self::makeSqlKey($key);

		if(!($sqlResource = $this->getSqliteHandler($key[0])))
			return false;

		$query = $sqlResource->query("SELECT * FROM cacheStore WHERE key LIKE '{$sqlKey}'");

		if($query !== false && $resultArray = $query->fetch($this->responseCode))
		{
			$returnData = StashUtilities::decode(base64_decode($resultArray['data']), $resultArray['encoding']);
			$results = array('expiration' => (int) $resultArray['expires'], 'data' => $returnData);
		}else{
			$results = false;
		}

		return $results;
	}

	/**
	 * This stores the data array into the SQLite database.
	 *
	 * @param array $data
	 * @param int $expiration
	 */
	public function storeData($key, $data, $expiration)
	{
		$sqlKey = self::makeSqlKey($key);
		$encoding = StashUtilities::encoding($data);
		$data = StashUtilities::encode($data);
		$data = base64_encode($data);

		if(!($sqlResource = $this->getSqliteHandler($key[0])))
			return false;

		$resetBusy = false;
		$contentLength = strlen($data);
		if($contentLength > 100000)
		{
			$resetBusy = true;
			self::setTimeout($sqlResource, $this->busyTimeout * (ceil($contentLength/100000))); // .5s per 100k
		}

		$query = $sqlResource->query("INSERT INTO cacheStore (key, expires, data, encoding)
											VALUES ('{$sqlKey}', '{$expiration}', '{$data}', '{$encoding}')");

		if($resetBusy)
			self::setTimeout($sqlResource, self::$busyTimeout);

		return true;
	}

	static function setTimeout($sqliteHandler, $milliseconds)
	{
		if($sqliteHandler instanceof PDO)
		{
			$timeout = ceil($milliseconds/1000);
			$sqliteHandler->setAttribute(PDO::ATTR_TIMEOUT, $timeout);
		}else{
			$sqliteHandler->busyTimeout($milliseconds);
		}
	}

	/**
	 * This function takes in a key array, turns it into an sql key, and deletes all objects in the database whose
	 * keys begin with this key. If the argument passed is null the entire cache directory is deleted, or if it is a
	 * single word key the appropriste sqlite database is removed.
	 *
	 * @param null|array $key
	 */
	public function clear($key = null)
	{
		if(is_null($key) || (is_array($key) && count($key) == 0))
		{
			self::$sqlObject = false;
			StashUtilities::deleteRecursive($this->cachePath);
		}elseif(is_array($key) && count($key) == 1){
			$name = array_shift($key);
			self::$sqlObject[$name] = false;
			unlink($this->cachePath . $name . '.sqlite');
		}else{
			$sqlKey = self::makeSqlKey($key);
			$sqlResource = $this->getSqliteHandler($key[0]);
			$query = $sqlResource->query("DELETE FROM cacheStore WHERE key LIKE '{$sqlKey}%'");
		}
		return true;
	}

	/**
	 * Removes all stale data from each of the sqlite databases that make up the cache.
	 *
	 * @return bool
	 */
	public function purge()
	{
		$filePath = $this->cachePath;

		$databases = glob($filePath . '*.sqlite');
		$expiration = time();
		foreach($databases as $database)
		{
			$tmpArray = explode('/', $database);
			$tmpArray = array_pop($tmpArray);
			$tmpArray = explode('.', $tmpArray);
			$cacheName = array_shift($tmpArray);

			$handler = $this->getSqliteHandler($cacheName);
			$handler->query('DELETE FROM cacheStore WHERE expires < ' . $expiration);
			$handler->query('VACUUM');
		}
		return true;
	}

	/**
	 * This function is used to retrieve an SQLiteDatabase object. If the requested section does not exist, it creates
	 * and and sets up the structure.
	 *
	 * @param string
	 * @return SQLiteDatabase|SQLite3
	 */
	public function getSqliteHandler($name)
	{
		try {

			if(isset(self::$sqlObject[$name]) && is_object(self::$sqlObject[$name]))
					return self::$sqlObject[$name];

			$path = $this->cachePath . $name . '.sqlite';

			if(!file_exists($path))
			{
				if(!is_dir($this->cachePath))
					mkdir($this->cachePath, $this->dirPermissions, true);
				$runInstall = true;
			}else{
				$runInstall = false;
			}

			if(class_exists('PDO'))
			{
				try{
					$drivers = PDO::getAvailableDrivers();
					if(in_array('sqlite', $drivers))
					{
						$db = new PDO('sqlite:' . $path);
					}elseif(in_array('sqlite2', $drivers)){
						$db = new PDO('sqlite2:' . $path);
					}

					$this->responseCode = PDO::FETCH_ASSOC;

				}catch(Exception $e){
					if(isset($db)) unset($db);
				}
			}

			if(!isset($db))
			{
				if(!$db = new SQLiteDatabase($path, $this->filePermissions, $errorMessage))
					throw new StashSqliteError('Unable to open SQLite Database: '. $errorMessage);

				$this->responseCode = SQLITE_ASSOC;
			}

			if($runInstall && !$db->query($this->creationSql))
			{
				unlink($path);
				throw new StashSqliteError('Unable to set SQLite: structure');
			}

			// prevent the cache from getting hungup waiting on a return
			self::setTimeout($db, $this->busyTimeout);

			self::$sqlObject[$name] = $db;

			return $db;

		}catch(Exception $e){
			return false;
		}
	}

	/**
	 * This function takes an array of strings and turns it into the sqlKey. It does this by iterating through the
	 * array, running the string through sqlite_escape_string() and then combining that string to the keystring with a
	 * delimiter.
	 *
	 * @param array $key
	 * @return string
	 */
	static function makeSqlKey($key)
	{
		$pathPiece = '';
		foreach($key as $rawPathPiece)
		{
			// Rather than use the sqlite_escape_string function, which may not exist, we simply strip out any
			// charactor that isn't a number or letter. Shockingly enough this is actually faster, although that
			// is not the reason for doing it.
			$pathPiece .= preg_replace('[^A-Za-z0-9]', '', $rawPathPiece ) . ':::';
		}

		return $pathPiece;
	}

	/**
	 * This function checks to see if it is possble to enable this handler. It does so here by making sure the
	 * SQLite3 or SQLiteDatabase class exists.
	 *
	 * @return bool
	 */
	static function canEnable()
	{
		if(class_exists('SQLiteDatabase', false))
			return true;

		if(!class_exists('PDO', false))
			return false;

		$drivers = PDO::getAvailableDrivers();
		return in_array('sqlite', $drivers) || in_array('sqlite2', $drivers);
	}

}

class StashSqliteError extends StashError {}
?>